Skip to content

wt 0.2.0: worktree lifecycle — new, switch, list, rm, merge - #1

Merged
VSN2015 merged 15 commits into
masterfrom
worktree-worktree-lifecycle
Sep 1, 2026
Merged

wt 0.2.0: worktree lifecycle — new, switch, list, rm, merge#1
VSN2015 merged 15 commits into
masterfrom
worktree-worktree-lifecycle

Conversation

@VSN2015

@VSN2015 VSN2015 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Adds a worktree-lifecycle layer to wt, so a worktree can be created, found, merged, and fully torn down — reclaiming the server, database, Redis slot, and state files the kit provisioned for it.

Inspired by worktrunk, which solves the complementary half: it creates and destroys worktrees but knows nothing about databases, servers, or isolation, so its remove leaks every resource wt provisions.

New commands

wt new <branch> [--from <base>] [--server]     create a branch + worktree, run the prepare hook
wt switch [<branch>]                            cd to a worktree; no argument opens a picker
wt list [--all]                                 branch, slug, status, server, isolation
wt rm [<branch>] [--keep-branch] [--force]      full teardown, then remove
wt merge [<branch>] [--into <trunk>] [-m <msg>] [--no-remove] [--force]
wt shell-init [bash|zsh|fish]                   the shell function that makes switch cd

Plus wt doctor checks for the path template, fzf, and shell integration; worktrees.path/worktrees.trunk and isolation.db_drop/isolation.redis_flush across all ten stack templates; README documentation; and a bump to 0.2.0.

Design

  • The lifecycle commands need only git. require_config is split into load_config_soft (never exits, sets HAS_CONFIG) and the existing strict wrapper. The ten pre-existing commands keep the strict wrapper and behave identically — verified by tracing every call site.
  • Stdout is a dedicated "cd channel". Lifecycle commands print a path there and nothing else; all human output goes to stderr, so the shell wrapper can cd to it.
  • The path template's last segment becomes the slug, and the slug determines the port, the Redis DB number, and the database name. Slug derivation itself is unchanged, so existing worktrees keep their identity.
  • Lookup is always git worktree list --porcelain, never a recomputed template — so it finds worktrees created by hand or by other tooling.

Safety

wt rm and wt merge stop servers, drop databases, flush Redis slots, and delete branches. wt merge also rewrites history and does not prompt.

  • wt merge writes refs/wt/premerge/<slug> before the first mutation; every failure path restores the branch and names that ref.
  • Rebase conflicts force the branch ref back explicitly — a mid-rebase HEAD is detached, so rebase --abort alone is not enough.
  • Trunk advances via --ff-only, so a merge commit is structurally unreachable.
  • A database is dropped only when a .dbowned provenance marker proves the kit bootstrapped it. Databases merely adopted by db_check — including every database created before 0.2.0 — are left in place with a notice. This errs toward keeping data.

Testing

New test/lifecycle.sh: 110 assertions, git-only (no docker, no YAML backend required). Runs on macOS and is wired into the Debian and Alpine container suites.

Verified on macOS under both the system shell and dash, including dash executing bin/wt itself.

Not yet verified: the container suites (./test/linux.sh) could not run — no docker daemon on the development machine. CONTEXT.md §5 records that three of this codebase's eight historical bugs surfaced under BusyBox ash first. Please run ./test/linux.sh before merging. Two tests derive a docker-free PATH and are the most likely to need adjustment on a Linux layout.

Also unexercised: both picker branches (fzf is not installed; the sandbox has no controlling terminal) and the emitted fish wrapper.

Note on docs/

Commits 18c11f5 and f936d19 add docs/superpowers/{specs,plans}/ — the design spec and implementation plan, ~1,900 lines. These were previously untracked. If you would rather keep them out of the repo, they can be dropped without touching any code.

Review pointers

The changes most worth a careful look:

  • remove_worktree_and_branch and teardown_worktree (bin/wt) — shared by both destructive commands; teardown borrows and restores the SLUG/N/WT_PATH globals deliberately.
  • cmd_merge's failure paths — the restore ordering is load-bearing.
  • expand_path's _esc helper — path values become sed replacement text, where & would otherwise mean "the whole match".
  • ensure_own_db — the one change to pre-existing code, adding the provenance marker.

🤖 Generated with Claude Code

VSN2015 and others added 15 commits August 29, 2026 16:13
need docker lived inside load_config_soft's config-present branch, so
any repo with a worktree-kit.yml — even one that just omits runner:,
which defaults to compose — made wt list die on a machine without the
docker CLI. That broke the premise of the lifecycle layer: those
commands need only git. Moved the check into require_config, after the
HAS_CONFIG gate, so load_config_soft genuinely never exits and the ten
pre-existing commands keep identical behavior (they all reach the check
through require_config). Added a regression test that scrubs docker
from PATH against a compose-defaulting config and asserts wt list still
works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cmd_new handed the prepare hook to `wt run` as its payload, but `wt run`
already runs hooks.prepare itself before execing its payload — running
the hook twice on every `wt new`, on both runners. `wt new --server`
made it three times, since `wt server` independently runs the hook as
part of its own command string.

Pass an inert `true` payload instead (wt run's own prepare step does
the work), and skip the standalone prepare step entirely when --server
is requested so `wt server`'s prepare run is the only one.
… removal

Five review findings on the destructive path: the unmerged-commits guard
failed open when a configured trunk did not resolve in the worktree; wt rm
from inside the worktree being removed crashed after git worktree remove
deleted the cwd, stranding the branch and skipping emit_cd; a failed
server-stop was silently swallowed instead of reported; .dbready alone did
not distinguish a kit-bootstrapped database from a merely-adopted one, so
wt rm (and soon wt merge) could drop a hand-created database — added a
narrower .dbowned marker and gated the drop on it; and the whole
HAS_CONFIG=1 teardown branch (server-stop, the ownership gate, db_drop,
redis_flush, the SLUG/N borrow) had zero test coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cmd_reset cleared .dbready but left a stale .dbowned behind. If the
database is later replaced by hand and db_check adopts the replacement
(touching only .dbready by design), the stale .dbowned would wrongly
authorize wt rm/merge to drop a database this incarnation of the slug
never created. Reset now clears both markers together; no heuristic
added to recover ownership after a reset, per instruction — the
accepted tradeoff is a "left in place" notice instead of a drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d, untested guards covered

- Rebase-conflict abort no longer trusts "rebase --abort" alone: falls back
  to checkout -f + an explicit update-ref of refs/heads/<branch> before the
  reset --hard, so a mid-rebase detached HEAD can never leave the branch
  ref silently rewritten while die claims it was restored.
- Guarded the reset --hard fallbacks in the squash-commit-failure and
  fast-forward-failure paths so a failing reset can't have set -e kill the
  function before its recovery message (and the backup ref) reach the user.
- Added cmd_rm's trunk-resolution guard (rev-parse --verify --quiet) so an
  unresolvable configured trunk dies with "cannot verify" instead of
  silently reading as "no commits ahead".
- Test: feat/conflict now gets two commits so the squash always changes
  history shape, closing a same-second-timestamp coincidence that let the
  restore assertion pass even with the restore code deleted; added a
  rev-list --count shape assertion as the load-bearing check.
- Test: backup ref assertion now checks value and timing (equals the
  pre-squash HEAD), not just that the ref name exists.
- Test: added coverage for the trunk-not-at-primary and
  upstream-without---force guards, with master/branch "untouched"
  assertions rather than bare assert_fails — needed because both guards,
  when disabled, can still exit nonzero for unrelated incidental reasons
  (a fast-forward into the wrong branch, or git's own branch -d safety
  check) while real damage already happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_drop

- test/lifecycle.sh: the "yields a space" doctor assertion matched the
  fixture's own path text regardless of whether the WARN line fired
  (cmd_doctor echoes the raw template unconditionally before the check
  runs). Assert on "wt new will refuse it" instead, which only the WARN
  line can produce. Verified load-bearing (RED/GREEN) along with the
  branch-unique assertion, which was already sound.
- templates/compose/django.yml: comment out db_drop — its db_bootstrap and
  own_db_env reveal no connection details at all, so the db/postgres host
  guess risked DROP DATABASE against the wrong server. redis_flush stays
  active.
- templates/{compose,host}/laravel.yml: note that the redis_flush host is
  an inference (this template exports REDIS_DB, not a REDIS_URL).
- bin/wt: usage() said shell-init "makes switch cd"; the wrapper actually
  intercepts switch/new/rm/merge.
…t aborts diagnosable

Root cause of a flake (1-in-5 observed): remove_worktree_and_branch was
called with force=0, so branch teardown used plain "git branch -d". After
a squash+rebase, the branch's tip is a new commit object, and git's -d
refuses to delete a branch that is merged into HEAD but not merged into
its own configured upstream tracking ref — a real but redundant check
here, since a successful --ff-only merge already proves every commit is
reachable from trunk. Whether that check fired depended on a same-second
commit-timestamp coincidence in the squash, and when it fired, "wt merge"
died under set -e; since the test's "wt merge ... --force" call was a bare
statement, that nonzero exit killed the whole suite silently — no FAIL
line, no LIFECYCLE FAIL, just truncated output.

- cmd_merge now passes force=1 to remove_worktree_and_branch (git branch
  -D), with a comment explaining why this is safe and warning against
  reverting it. Accepted consequence: worktree removal also becomes
  --force (discards untracked files) — not routed around, since refusing
  at this point would strand the user with trunk already advanced and the
  database already dropped.
- Wrapped every bare "$WT" new/merge invocation in the Task 5 test block
  (not already inside assert_fails) with "|| fail ...", so a future
  regression produces a visible FAIL line and reaches LIFECYCLE FAIL
  instead of silently truncating the run.

Verified: reverting force=1 alone reproduced the flake immediately (now
failing loudly instead of silently); with both fixes applied, 30
consecutive full-suite runs plus 4 dash runs, all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emoval

Whole-branch review of the lifecycle layer found seven defects that the
per-task reviews could not see, because each only spanned one command.

bin/wt:

- remove_worktree_and_branch ran four bare git calls AFTER teardown had
  already stopped the server, dropped the database and deleted every state
  file. A failure there aborted under set -e with only git's stderr: no
  "removed" line, no emit_cd, so a shell-wrapper caller was left in a
  deleted directory. The worktree removal now dies with a message saying
  the reclaim already happened; branch deletion only notes. The reachable
  trigger was `git branch -d`, which asks "merged into HEAD?" — not the
  "merged into trunk?" question cmd_rm already answered — so removing a
  sibling worktree could strand a branch wt had verified.

- teardown_worktree borrowed SLUG and N for the target but not WT_PATH,
  which is what runner_exec keys off. hooks.prepare therefore re-ran in the
  CALLER's worktree on every rm/merge of another worktree, and db_drop /
  redis_flush resolved relative paths against the wrong tree. The unused
  _tp parameter was the tell.

- expand_path fed {parent}, {repo}, {branch_raw} and {home} to sed as
  replacement text, where & means the whole match. The default template
  uses two of them, so a repo directory named "R&D-app" silently expanded
  {repo} to "R{repo}D-app" — and that path's last segment names the slug,
  the port, the redis slot and the database.

- $HOME was dereferenced unguarded under set -u in the {home} expansion and
  the rc-file scan; an unset HOME (env -i, some CI runners) died bare.

- The fish wrapper used a single-command variable override, a syntax error
  on older fish, which would break wt entirely for those users.

- The no-.dbowned notice claimed the database was "adopted, not created by
  wt". That is false for every worktree provisioned before this release —
  .dbowned did not exist — which is exactly who sees it first on upgrade.

- A failed restore in cmd_merge printed "could not restore" and then died
  saying "restored to <sha>", on the one path where the user is panicking.
  One helper now prints a single, true outcome.

test/lifecycle.sh (84 -> 110 assertions):

- Both PATH=/usr/bin:/bin scrubs would have misbehaved in the container
  suites: docker lives in /usr/bin on Linux, inverting the server-stop
  assertion, and the truncation can remove the YAML backend. They now drop
  only directories that hold docker, shimming git and the backend first.

- Guarded ~20 setup calls and captures that ran bare under set -eu, so a
  regression FAILs with a diagnostic instead of truncating the run.

- First coverage of worktrees.path from the committed config (only the
  .local path was exercised); stdout purity for rm and merge; the reason
  behind the three refusals that matter; a real rebase-state check; and the
  removal of an assertion that had no code path able to fail it.

Docs: README spells out that removal deletes the whole directory including
gitignored files, that merge tears down like `rm --force` rather than `rm`,
that hyphens fold in {branch}, and how pre-0.2.0 databases behave. The
design spec is amended in three places where the implementation knowingly
diverged, each marked as a resolved deviation.

Verified on /bin/sh and dash, six consecutive runs, 110 assertions, no
flake. test/linux.sh not run — docker daemon is down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anup

The db_drop bullet told upgrading users that `wt reset <slug>` plus an
--own-db cycle would re-bootstrap and re-mark a pre-0.2.0 database. It
cannot: reset clears both markers, but ensure_own_db then runs db_check,
which succeeds because the database still exists, and returns having
touched only .dbready. .dbowned is written solely on the db_bootstrap
path, which runs only when the database is missing. The bullet therefore
contradicted its own case (b) two lines further down. It now says to drop
the database by hand first, and explains why reset alone is not enough.

Also amends spec §10.1 step 7, which still listed four state files while
the implementation removes .dbowned as well — a fourth divergence, marked
like the other three, and the status header updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary & Overall Impression

This is a very well-engineered pull request that adds a complete worktree-lifecycle management layer (new, switch, list, rm, merge, shell-init) to wt.

Highlights

  • Clean Separation of Concerns: Splitting config loading into load_config_soft and require_config allows wt lifecycle commands to work seamlessly in any Git repository without requiring Docker or a worktree-kit.yml.
  • Robust Safety Invariants in wt rm and wt merge:
    • Pre-merge backup ref (refs/wt/premerge/<slug>) created prior to any mutations.
    • Safe rebase conflict recovery (bin/wt lines 1153–1166) that accounts for detached HEAD state and explicitly restores the branch ref.
    • Ownership provenance gating via .dbowned ensuring wt never drops databases it did not create.
    • CWD deletion protection (bin/wt lines 1068, 1187) that steps the process out to $PRIMARY before git worktree remove deletes the working directory.
  • Dedicated Stdout "CD Channel": Clean design separating machine-readable stdout path emission for shell integration from human stderr diagnostics.
  • Comprehensive Test Suite: test/lifecycle.sh provides 110 assertions verifying failure paths, rollback behavior, and edge cases.

Critical Findings & Bugs

1. Relative Invocation (./bin/wt) Fails in Subshells (cd Breaks "$0")

  • Severity: High
  • Location: bin/wt lines 437, 739, 948, 955

Issue:
When running wt via a relative path (e.g. ./bin/wt new <branch> or bin/wt up), $0 is ./bin/wt. When cmd_new, cmd_up, or teardown_worktree executes a subshell that changes directory (cd "$_path" && "$0" ...), "$0" is evaluated relative to the new working directory, where ./bin/wt does not exist.

Reproducing failure:

$ ./bin/wt new feature-x
wt: prepare: true
./bin/wt: line 948: ./bin/wt: No such file or directory
wt: prepare hook failed — worktree left at ...

Recommended Fix:
Resolve the absolute path to the wt executable once during initialization and use that resolved variable (WT_BIN or SELF) throughout:

# Early in bin/wt (or in ctx_init):
case "$0" in
  /*) WT_BIN="$0" ;;
  */*) WT_BIN="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" ;;
  *) WT_BIN="$(command -v "$0" 2>/dev/null || echo "$0")" ;;
esac

Then replace "$0" at lines 437, 739, 948, and 955 with "$WT_BIN".


2. wt doctor Crashes on runner: compose Repos If Docker CLI Is Not Installed

  • Severity: Medium
  • Location: bin/wt lines 850–851

Issue:
cmd_doctor calls require_config, which calls need docker when runner: compose is set. If Docker is not installed on the host, wt doctor aborts immediately with wt: missing dependency: docker at line 850 before checking paths, YAML backends, fzf, shell integration, or overlays.

Furthermore, line 870 was specifically designed to inspect Docker status (docker: up / docker: DOWN), but it can never be reached if the Docker CLI is absent.

Recommended Fix:
Use load_config_soft instead of require_config inside cmd_doctor, and check Docker availability gracefully:

cmd_doctor() {
  echo "wt $WT_VERSION"
  echo "primary:   $PRIMARY"
  echo "worktree:  $WT_PATH (slug: $SLUG, n: $N)"
  load_config_soft
  if [ "$HAS_CONFIG" = 1 ]; then
    echo "config:    $CONFIG (runner: $RUNNER, cache: $WT_CFG_SRC)"
  else
    echo "config:    MISSING — run wt init"
  fi
  ...
  if [ "${RUNNER:-compose}" = compose ]; then
    if ! command -v docker >/dev/null 2>&1; then
      echo "docker:    MISSING — install docker for runner: compose"
    elif docker info >/dev/null 2>&1; then
      echo "docker:    up"
    else
      echo "docker:    DOWN"
    fi
  fi

3. Test Runner Ergonomics in test/lifecycle.sh

  • Severity: Low / DX
  • Location: test/lifecycle.sh line 7

Issue:
WT="${WT:-wt}" defaults to wt from PATH.

  1. If a developer runs ./test/lifecycle.sh as instructed in the file header, it uses whatever wt binary is installed in their global system path (or fails if not installed), rather than testing bin/wt from the repository.
  2. Passing WT=./bin/wt ./test/lifecycle.sh fails because the test changes directory into temporary repos under $TMP, where relative ./bin/wt does not exist.

Recommended Fix:
Default WT to an absolute path pointing to the local bin/wt:

if [ -z "${WT:-}" ]; then
  _script_dir="$(cd "$(dirname "$0")/.." && pwd)"
  if [ -f "$_script_dir/bin/wt" ]; then
    WT="$_script_dir/bin/wt"
  else
    WT="wt"
  fi
fi

Detailed Review by Component

A. Lifecycle Commands & Execution

  1. cmd_new:

    • Space checks on expanded paths (bin/wt lines 921–926) prevent silent string splitting bugs in compose mounts.
    • Sed replacement character escaping via _esc (bin/wt line 372) properly protects directory names with & and branch names with |.
    • Prepare hook execution logic cleanly avoids double-execution for both --server and standalone cases.
  2. cmd_merge:

    • All precondition guards fire before any history or refs are mutated.
    • Fallback on conflict re-attaches HEAD and resets the branch ref to $_orig.
    • --ff-only merge into trunk ensures no merge commits are created.
  3. cmd_rm & teardown_worktree:

    • State files (.pid, .port, .dbready, .dbowned, logs/*.log) are properly cleared.
    • Global variable borrowing (SLUG, N, WT_PATH) correctly scopes teardown and prepare hooks to the target worktree.
  4. cmd_shell_init:

    • Using env WT_SHELL_INTEGRATION=1 wt $argv for Fish provides compatibility across older and newer Fish shell releases.
    • Shell function traps non-zero exits while allowing empty cd-channel paths.

B. Templates & Documentation

  • All 10 stack templates in templates/host/ and templates/compose/ are syntactically valid YAML and contain the new worktrees.path, worktrees.trunk, isolation.db_drop, and isolation.redis_flush definitions.
  • README.md documentation is detailed, accurately explaining shell integration, the cd-channel mechanism, and warning about untracked/ignored file removal during teardown.

Repository owner deleted a comment from jackt000 Sep 1, 2026
@VSN2015
VSN2015 merged commit f936d19 into master Sep 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant